订单退款领域建模与 Saga
2026-06-02 → 06-04 · v0.6.1
主仓里程碑日记改写。日报、文件清单、自测 SQL 已去掉,只留对外还值得看的决策。
占位接口变真之后,还剩一类问题:订单列表看得见点不动,退款是五个 TODO 拼出来的,申请用 Map<String, Object> 表示。这一版用真退款练 DDD——简单的一次做完,复杂的沉到以后,不装「一次做完所有金融闭环」。
一句话
订单聚合故意做薄,退款申请做成厚聚合;部分退款 Saga 拆成「先落库等审批 → 通过后再退」。
为什么订单薄、退款厚
| OrderAggregate | RefundApplicationAggregate | |
|---|---|---|
| 当下行为 | 建单 / 标已支付 / 标退款 / 关单 | 6 个状态、4 类合法跃迁、金额与审批人校验 |
| 规则复杂度 | 低,硬塞充血方法是空壳 | 高,必须聚在聚合根里才能演进 |
| 选型 | 薄:SQL 级状态预检 + UPDATE | 厚:状态机 + LEGAL_TRANSITIONS |
「严格 DDD 就要所有聚合都充血」是执念。依据应该是 业务规则复杂度 + 重复出现的频度。订单以后如果出现 3 处以上复制粘贴的退款判断,再升级为厚聚合(ADR-006 写了这条触发线)。
Saga 为什么要两段
第一版 PartialRefundSaga 是一把 execute 做完。部分退款必须等人审,链路必然中断。
execute
→ 落库 PENDING_REVIEW
→ 标记 WAITING_FOR_APPROVAL
↓
管理员 approve
→ 聚合 APPROVED
↓
executeAfterApproval
→ startRefunding → 调支付退款 → REFUNDED / REFUND_FAILEDWAITING_FOR_APPROVAL 和「等支付回调」是同一种中断:Saga 停在人为输入上,而不是假装一次远程调用就能结束。
v0.6.1 选择 审批请求里同步触发后半段,管理员刷新就能看到结果。代价是微信退款可能拖几秒。兜底已经有:先把聚合落成 APPROVED,Saga 失败则 REFUND_FAILED,定时任务接管重试。支付抖得厉害再改成事件异步,不必这版推翻。
状态机
PENDING_REVIEW ──→ APPROVED ──→ REFUNDING ──→ REFUNDED
│ ↓
│ REFUND_FAILED ──→ 可再进 REFUNDING
└────────→ REJECTED合法跃迁集中在聚合根的静态表里,canTransitionTo() 强制校验。订单号也不再满天飞 UUID,统一成 ACT_yyyyMMdd_xxxxxx / REF_yyyyMMdd_xxxxxx。
几个当时没做、并且做对了的选择
- 复杂业务必须有沉淀处。 通知、按进度算金额、大额分流、对账,都标了 v0.6.2,并且同时写进待办文档。只埋代码注释,三个月后一定丢。
- 单例 Saga 的
setState不并发安全。 后台同时点同一笔退款的概率接近 0,这版只加警告,不搞过早重构。 - 审批入口不放 Controller。
RefundApprovalApplicationService统一恢复 Saga 上下文。以后改异步,只改这一处。
和本栏目其它文的关系
- 取消活动的长事务:标准 DDD 架构 · 活动取消 Saga
- 事件为什么会丢:事件生命周期与 Outbox 事故复盘
- 加一个新聚合怎么走:新业务开发完整指南